Exploring Favourite Recipes

Recipe websites allow you to bookmark certain recipes as "favourites". A student named Jeremy Cohen pulled together a sample of such data for an excellent machine learning project and we'll use his dataset to demo how to do some unsupervised machine learning with MLDB.

The notebook cells below use pymldb's Connection class to make REST API calls. You can check out the Using pymldb Tutorial for more details.


In [13]:
from pymldb import Connection
mldb = Connection("http://localhost/")

The sequence of procedures below is based on the one explained in the Mapping Reddit demo notebook.

First we import the raw data and make a sparse matrix out of it.


In [14]:
print mldb.put('/v1/procedures/import_rcp', {
    "type": "import.text",
    "params": {
        "headers": ["user_id", "recipe_id"],
        "dataFileUrl": "http://public.mldb.ai/favorites.csv.gz",
        "outputDataset": "rcp_raw",
        "runOnCreation": True
    }
})

print mldb.post('/v1/procedures', {
    "id": "rcp_import",
    "type": "transform",
    "params": {
        "inputData": "select pivot(recipe_id, 1) as * named user_id from rcp_raw group by user_id",
        "outputDataset": "recipes",
        "runOnCreation": True
    }
})


<Response [201]>
<Response [201]>

We then train an SVD decomposition and do K-Means clustering


In [76]:
print mldb.post('/v1/procedures', {
    "id": "rcp_svd",
    "type" : "svd.train",
    "params" : {
        "trainingData": "select * from recipes",
        "columnOutputDataset" : "rcp_svd_embedding_raw",
        "runOnCreation": True
    }
})

num_centroids = 16

print mldb.post('/v1/procedures', {
    "id" : "rcp_kmeans",
    "type" : "kmeans.train",
    "params" : {
        "trainingData" : "select * from rcp_svd_embedding_raw",
        "outputDataset" : "rcp_kmeans_clusters",
        "centroidsDataset" : "rcp_kmeans_centroids",
        "numClusters" : num_centroids,
        "runOnCreation": True
    }
})


<Response [201]>
<Response [201]>

Now we import the actual recipe names, clean them up a bit, and get a version of our SVD embedding with the recipe names as column names.


In [77]:
print mldb.put('/v1/procedures/import_rcp_names_raw', {
    'type': 'import.text',
    'params': {
        'dataFileUrl': 'http://public.mldb.ai/recipes.csv.gz',
        'outputDataset': "rcp_names_raw",
        'delimiter':'',
        'quoteChar':'',
        'runOnCreation': True
    }
})

print mldb.put('/v1/procedures/rcp_names_import', {
    'type': 'transform',
    'params': {
        'inputData': '''
            select jseval(
               'return s.substr(s.indexOf(",") + 1)
                .replace(/&#34;/g, "")
                .replace(/&#174;/g, "");',
            's', lineText) as name
            named implicit_cast(rowName()) - 1
            from rcp_names_raw
        ''',
        'outputDataset': 'rcp_names',
        'runOnCreation': True
    }
})

print mldb.put('/v1/procedures/rcp_clean_svd', {
    'type': 'transform',
    'params': {
        'inputData': """
            select rcp_svd_embedding_raw.* as *
            named rcp_names.rowName()+'-'+rcp_names.name 
            from rcp_svd_embedding_raw
                join rcp_names on (rcp_names.rowName() = rcp_svd_embedding_raw.rowPathElement(0))
        """,
        'outputDataset': {'id': 'rcp_svd_embedding',
                          'type': 'embedding',
                          'params': {'metric': 'cosine'}},
        'runOnCreation': True
    }
})


<Response [201]>
<Response [201]>
<Response [201]>

With all that pre-processing done, let's look at the names of the 3 closest recipes to each cluster centroid to try to get a sense of what kind of clusters we got.


In [78]:
mldb.put("/v1/functions/nearestRecipe", {
    "type":"embedding.neighbors",
    "params": { "dataset": "rcp_svd_embedding", "defaultNumNeighbors": 3 }
})

mldb.query("""

select nearestRecipe({coords: {*}})[neighbors] as * from rcp_kmeans_centroids

""").applymap(lambda x: x.split('-')[1])


Out[78]:
0 1 2
_rowName
0 African Curry Chap Chee Noodles Sesame Crusted Mahi Mahi with Soy Shiso Ginger...
1 Traditional Christmas Cheese Ball Old School Mac n' Cheese Superb Sauteed Mushrooms
2 Cheese Grits Teriyaki Mushrooms Country Scalloped Potatoes
3 Cranberry Bars Cranberry Upside Autumn Harvest Cookies
4 Fabulous French Loaves Tasty Buns Mama D's Italian Bread
5 Blueberry Cream Cheese Pound Cake I Blueberry Cream Cheese Pound Cake II Hawaiian Banana Nut Bread
6 Pro Ganache Coffee Butter Frosting Strawberry Cake and Frosting I
7 Angel's Chunky Chicken Salad Baked Ham Incredible Potato Casserole
8 Sausage Flowers Fried Cabbage with Bacon, Onion, and Garlic Sesame Noodle Salad
9 Coffee Shake Herbie's Home Fries Chocolate Wontons
10 Poblano Chile Enchiladas a la Gringa Carnitas Filling Daddy's 'If They'da had This at the Alamo we w...
11 Vegan Red Lentil Soup Spinach, Red Lentil, and Bean Curry Lentils And Spinach
12 Slow Cooker Thanksgiving Turkey Slow Cooker BBQ Pork Chops Slow Cooker Pork Chops
13 Colette's Smoked Sausage Fritatta Filet Mignons With Pepper Cream Sauce Hidden Cheeseburger
14 Spicy Flank Steak Blissful Rosemary Chicken Chicken Melt
15 Chicago Dip Spaghetti Salad I Cheesy Potato Casserole

We can see a bit of pattern just from the names of the recipes nearest to the centroids, but we can probably do better! Let's try to extract the most characteristic words used in the recipe names for each cluster.

Topic Extraction with TF-IDF

We'll start by preprocessing the recipe names a bit: taking out a few punctuations and convert to lowercase. And then for a given cluster, we will count the words taken from the recipe names. This is all done in one query.


In [84]:
print mldb.put('/v1/procedures/sum_words_per_cluster', {
    'type': 'transform',
    'params': {
        'inputData': """
            select sum({tokens.* as *}) as * 
            named c.cluster
            from (
                SELECT lower(n.name),
                    tokenize('recipe ' + lower(n.name), {splitChars:' -.;&!''()",', minTokenLength: 4}) as tokens,
                    c.cluster
                FROM rcp_names as n 
                    JOIN rcp_kmeans_clusters as c ON (n.rowName() = c.rowPathElement(0))
                order by n.rowName()
            )
            group by c.cluster
        """,
        'outputDataset': 'rcp_cluster_word_counts',
        'runOnCreation': True
    }
})

mldb.query("""select * from rcp_cluster_word_counts order by implicit_cast(rowName())""")


<Response [201]>
Out[84]:
absolutely acorn adobo adrienne african aguadito alaskan alaska alfredo alla ... vicious vienna wassail weeknight whit willyboy winner wrapper yankee yumazuti
_rowName
0 3 1 2 1 1 1 1 1 1 1 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
1 1 NaN NaN NaN NaN NaN NaN NaN 2 1 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
2 1 NaN NaN NaN NaN NaN NaN 1 2 1 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
4 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
5 2 NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
6 NaN NaN NaN NaN NaN NaN NaN NaN 1 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
7 NaN 1 NaN NaN NaN NaN NaN NaN 8 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
8 1 1 NaN NaN NaN NaN NaN NaN 2 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
9 3 NaN 1 NaN NaN NaN NaN NaN 2 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
10 1 NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
11 NaN 5 NaN NaN 3 NaN NaN NaN 1 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
12 NaN NaN 1 NaN NaN NaN NaN NaN 2 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
13 NaN 1 1 NaN 1 NaN NaN NaN 5 1 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
14 NaN NaN 1 NaN NaN NaN 1 NaN 2 2 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
15 1 NaN NaN 1 NaN NaN NaN NaN 8 NaN ... 1 1 1 1 1 1 1 1 1 1

16 rows × 3091 columns

We can use this to create a TF-IDF score for each word in the cluster. Basically this score will give us an idea of the relative importance of a each word in a given cluster.


In [82]:
print mldb.put('/v1/procedures/train_tfidf', {
     'type': 'tfidf.train',
     'params': {
         'trainingData': "select * from rcp_cluster_word_counts",
         'modelFileUrl': 'file:///mldb_data/models/rcp_tfidf.idf',
         'runOnCreation': True
    }
})

print mldb.put('/v1/functions/rcp_tfidf', {
     'type': 'tfidf',
     'params': {
         'modelFileUrl': 'file:///mldb_data/models/rcp_tfidf.idf',
         'tfType': 'log', 'idfType': 'inverse'
    }
})


print mldb.put('/v1/procedures/apply_tfidf', {
     'type': 'transform',
     'params': {
         'inputData': "select rcp_tfidf({input: {*}})[output] as * from rcp_cluster_word_counts",
         'outputDataset': 'rcp_cluster_word_scores',
         'runOnCreation': True
    }
})

mldb.query("select * from rcp_cluster_word_scores order by implicit_cast(rowName())")


<Response [201]>
<Response [201]>
<Response [201]>
Out[82]:
absolutely acorn adobo adrienne african aguadito alaskan alaska alfredo alla ... vicious vienna wassail weeknight whit willyboy winner wrapper yankee yumazuti
_rowName
0 0.797624 0.679859 1.077551 1.160312 0.960906 1.441359 1.160312 1.160312 0.143925 0.679859 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
1 0.398812 NaN NaN NaN NaN NaN NaN NaN 0.228115 0.679859 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
2 0.398812 NaN NaN NaN NaN NaN NaN 1.160312 0.228115 0.679859 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
4 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
5 0.632102 NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
6 NaN NaN NaN NaN NaN NaN NaN NaN 0.143925 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
7 NaN 0.679859 NaN NaN NaN NaN NaN NaN 0.456230 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
8 0.398812 0.679859 NaN NaN NaN NaN NaN NaN 0.228115 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
9 0.797624 NaN 0.679859 NaN NaN NaN NaN NaN 0.228115 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
10 0.398812 NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
11 NaN 1.757410 NaN NaN 1.921812 NaN NaN NaN 0.143925 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
12 NaN NaN 0.679859 NaN NaN NaN NaN NaN 0.228115 NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
13 NaN 0.679859 0.679859 NaN 0.960906 NaN NaN NaN 0.372040 0.679859 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
14 NaN NaN 0.679859 NaN NaN NaN 1.160312 NaN 0.228115 1.077551 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
15 0.398812 NaN NaN 1.160312 NaN NaN NaN NaN 0.456230 NaN ... 1.441359 1.441359 1.441359 1.441359 1.441359 1.441359 1.441359 1.441359 1.441359 1.441359

16 rows × 3091 columns

If we transpose that dataset, we will be able to get the highest scored words for each cluster, and we can display them nicely in a word cloud.


In [83]:
import json
from ipywidgets import interact 
from IPython.display import IFrame, display
html = """
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.6/d3.min.js"></script>
<script src="https://static.mldb.ai/d3.layout.cloud.js"></script>
<script src="https://static.mldb.ai/wordcloud.js"></script>
<body> <script>drawCloud(%s)</script> </body>
"""

@interact 
def cluster_word_cloud(cluster=[0, num_centroids-1]):
    num_words = 20
    cluster_words = mldb.get(
        '/v1/query',
        q="""
            SELECT rowName() as text
            FROM transpose(rcp_cluster_word_scores)
            ORDER BY "{0}" DESC
            LIMIT {1}
          """.format(cluster, num_words),
        format='aos',
        rowNames=0
    ).json()
    for i,x in enumerate(cluster_words):
        x['size'] = num_words - i
    display( IFrame("data:text/html," + (html % json.dumps(cluster_words)).replace('"',"'"), 850, 350) )


Much better!

Where to next?

Check out the other Tutorials and Demos.